package jmine.tec.script;

import static jmine.tec.script.impl.beanshell.BeanshellMessages.ERRO_EXECUCAO_SCRIPT;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.MathContext;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import jmine.tec.annotations.Unmodifiable;
import jmine.tec.script.impl.AbstractScriptExecutor;
import jmine.tec.script.impl.beanshell.BeanshellScriptExecutor;
import jmine.tec.script.impl.beanshell.BeanshellScriptExecutorException;
import jmine.tec.utils.collection.impl.CopyOnWriteHashMap;
import jmine.tec.utils.debug.impl.DebugRuntimeFactory;

import org.apache.log4j.Logger;

import bancosys.tec.exception.AbstractException;
import bancosys.tec.exception.BusinessException;
import bancosys.tec.persist.dao.BeanNotFoundException;
import bsh.CompiledScript;
import bsh.CompilerImpl;
import bsh.LoadedTrait;
import bsh.ParseException;
import bsh.Prototype;
import bsh.Trait;
import bsh.compilation.AbstractCompilationSource;
import bsh.compilation.CompilationOptions;
import bsh.compilation.CompilationResults;
import bsh.compilation.CompilationSource;
import bsh.compilation.DebugOptions;
import bsh.compilation.DefaultCompilationOptions;
import bsh.exception.CompilationFailedException;
import bsh.utils.reflection.OpenClassLoader;

/**
 * Versao compilada do beanshellexecutor
 * 
 * @author takeshi
 */
@Unmodifiable
public class CompiledBeanshellExecutor extends AbstractScriptExecutor implements ScriptExecutor, WrappingScriptExecutor {

    private static final Logger LOG = Logger.getLogger(CompiledBeanshellExecutor.class);

    private final bsh.Compiler compiler = new CompilerImpl();

    private static final OpenClassLoader LOADER = new OpenClassLoader();

    private final Map<String, Class<? extends CompiledScript>> scriptCache =
            new CopyOnWriteHashMap<String, Class<? extends CompiledScript>>();

    private final Map<String, LoadedTrait> traitsCache = new CopyOnWriteHashMap<String, LoadedTrait>();

    private static long uuidGenerator = 0;

    private final Class<? extends CompiledScript> baseClass;

    /**
     * C'tor que usa o {@link CompiledScript} como super classe
     */
    public CompiledBeanshellExecutor() {
        this(Prototype.class);
    }

    /**
     * C'tor que sobrescreve a classe base
     * 
     * @param baseClass a classe base a ser usada
     */
    public CompiledBeanshellExecutor(Class<? extends CompiledScript> baseClass) {
        this.baseClass = baseClass;
    }

    /**
     * Devolve o script instanciado.
     * 
     * @param <R> o tipo de resultado do contexto.
     * @param context o contexto de execução.
     * @return o script instanciado.
     * @throws Exception e
     */
    private <R> CompiledScript getScript(ScriptExecutionContext<R> context) throws Exception {
        if (context instanceof DecomposableScriptExecutionContext) {
            Class<? extends CompiledScript> scriptWithTraits =
                    this.compileScriptWithTraits((DecomposableScriptExecutionContext<?>) context);
            return this.instantiateScript(context, scriptWithTraits);
        } else {
            Class<? extends CompiledScript> scriptType = this.compileScript(context);
            return this.instantiateScript(context, scriptType);
        }
    }

    /**
     * Instantiates a script
     * 
     * @param context the context
     * @param scriptType the script type (class actually)
     * @return a compiled script
     * @throws InstantiationException e
     * @throws IllegalAccessException e
     * @param <R> the context type
     */
    public <R> CompiledScript instantiateScript(ScriptExecutionContext<R> context, Class<? extends CompiledScript> scriptType)
            throws InstantiationException, IllegalAccessException {
        CompiledScript instance = scriptType.newInstance();
        for (Entry<String, Object> entry : context.getParameters().getParameterValues().entrySet()) {
            if (entry.getValue() != null) {
                instance.setField(entry.getKey(), entry.getValue());
            }
        }
        instance.setMathContext(MathContext.DECIMAL128);
        return instance;
    }

    /**
     * Cria um initializer
     * 
     * @param initializerText o texto do initializer
     * @param name o nome
     * @return {@link Class}
     * @throws Exception e
     */
    public Class<? extends CompiledScript> createInitializer(final String initializerText, String name) throws Exception {
        synchronized (CompiledBeanshellExecutor.class) {
            Class<? extends CompiledScript> class1 = this.scriptCache.get(initializerText);
            if (class1 != null) {
                return class1;
            }
            CompilationResults results = this.compileInitializer(initializerText, name);

            if (DebugRuntimeFactory.ENABLED) {
                LOG.debug("parse time: " + results.getParseTimeMillis());
                LOG.debug("compilation time: " + results.getCompileTimeMillis());
                writeToFile(results, "generated/" + name + ".class");
            }

            final Class<? extends CompiledScript> defined = this.loadClass(results);
            this.scriptCache.put(initializerText, defined);
            return defined;
        }
    }

    /**
     * Faz a compilacao do inicializador.
     * 
     * @param initializerText o script de inicializacao
     * @param name o nome do script de inicializacao
     * @return {@link CompilationResults}
     * @throws ParseException e
     * @throws CompilationFailedException e
     */
    protected CompilationResults compileInitializer(final String initializerText, String name) throws ParseException,
            CompilationFailedException {
        CompilationSource source = new AbstractCompilationSource("generated/" + name, name + ".bsh") {
            public Reader openReader() {
                return new StringReader(initializerText);
            }

            @Override
            public Class<? extends CompiledScript> superType() {
                return CompiledBeanshellExecutor.this.baseClass;
            }

            @Override
            public Map<String, Class<?>> typeAlias() {
                final Map<String, Class<?>> map = super.typeAlias();
                CompiledBeanshellExecutor.this.addCommonImports(map);
                map.put("NumberFormat", NumberFormat.class);
                map.put("DecimalFormat", DecimalFormat.class);
                map.put("SimpleDateFormat", SimpleDateFormat.class);
                return map;
            }

        };
        final DefaultCompilationOptions options = new DefaultCompilationOptions() {
            @Override
            public boolean allowUndefinedVariables() {
                return true;
            }
        };
        CompilationResults results = this.compiler.compileOverrideOptions(source, options);
        return results;
    }

    /**
     * Forca o carregamento de uma classe
     * 
     * @param results {@link CompilationResults}
     * @return {@link Class} subclasse de {@link CompiledScript}
     */
    @SuppressWarnings("unchecked")
    private Class<? extends CompiledScript> loadClass(CompilationResults results) {
        return ((Class<? extends CompiledScript>) LOADER.load(results));
    }

    /**
     * Escreve um arquivo de .class para um arquivo
     * 
     * @param r {@link CompilationResults}
     * @param source String
     */
    public static void writeToFile(CompilationResults r, String source) {
        try {
            if (DebugRuntimeFactory.ENABLED) {
                new File("target/test-classes/generated").mkdirs();
                final String parent = "target/test-classes/generated";
                final File file = new File(parent, source + ".class");
                FileOutputStream fout = new FileOutputStream(file);
                fout.write(r.getBytecode());
                fout.close();

                for (Entry<String, byte[]> entry : r.getDependentTypes().entrySet()) {
                    String fName = entry.getKey().replace('/', '_') + ".class";
                    FileOutputStream f = new FileOutputStream(new File(parent, fName));
                    f.write(entry.getValue());
                    f.close();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * Compila um script
     * 
     * @param context {@link ScriptExecutionContext}
     * @return {@link Class}
     * @throws Exception e
     */
    public Class<? extends CompiledScript> compileScript(final ScriptExecutionContext<?> context) throws Exception {
        final String key = this.createContextKey(context);
        Class<? extends CompiledScript> cached = this.scriptCache.get(key);
        if (cached != null) {
            return cached;
        }
        synchronized (CompiledBeanshellExecutor.class) {
            try {
                CompilationResults results = this.doCompileScript(context);
                final Class<? extends CompiledScript> script = this.loadClass(results);
                this.scriptCache.put(key, script);
                return script;
            } catch (Exception e) {
                this.scriptCache.put(key, null);
                throw e;
            }
        }
    }

    /**
     * Cria a chave do script contido no contexto.
     * 
     * @param context context
     * @return key
     */
    private String createContextKey(final ScriptExecutionContext<?> context) {
        return context.getScriptName() + "|" + context.getScriptId() + "|" + context.getVersion();
    }

    /**
     * Creates a key to be used in the cache
     * 
     * @param definition context
     * @return key
     */
    private String createContextKey(ScriptDefinition definition) {
        return definition.name() + "|" + definition.identifier() + "|" + definition.version();
    }

    /**
     * Compile a {@link ScriptDefinition} as Trait and loads it
     * 
     * @param definition {@link ScriptDefinition}
     * @return {@link LoadedTrait}
     * @throws ParseException e
     * @throws CompilationFailedException e
     */
    public LoadedTrait compileAsTrait(final ScriptDefinition definition) throws ParseException, CompilationFailedException {
        String key = this.createContextKey(definition);
        if (this.traitsCache.containsKey(key)) {
            return this.traitsCache.get(key);
        }

        final Map<String, Class<?>> localTypeAliases = new HashMap<String, Class<?>>();
        addCommonImports(localTypeAliases);

        final Set<String> wildCards = new HashSet<String>();

        for (ScriptDefinition trait : definition.traits()) {
            LoadedTrait dep = this.compileAsTrait(trait);
            localTypeAliases.putAll(dep.allTypeImports());
            wildCards.addAll(dep.allWildcardImports());
        }

        CompilationSource source = new AbstractCompilationSource(this.generateClassName(definition.name()), definition.name() + ".bsh") {

            public Reader openReader() {
                return new StringReader(definition.scriptBody());
            }

            @Override
            public List<String> wildcardImports() {
                return new ArrayList<String>(wildCards);
            }

            @Override
            public Map<String, Class<?>> typeAlias() {
                return localTypeAliases;
            }
        };
        final Trait trait = this.compiler.compileAsTrait(source, this.getCompilationOptions());
        final LoadedTrait theTrait = trait.load(getLoader());
        this.traitsCache.put(key, theTrait);
        return theTrait;
    }

    /**
     * Compiles a {@link ScriptDefinition} with traits
     * 
     * @param definition the {@link ScriptDefinition}
     * @param parameterTypes the parameter types
     * @param constants the {@link Map} with constants
     * @param superType the super type
     * @param imports the list of imports
     * @return {@link Class}
     * @throws ParseException e
     * @throws CompilationFailedException e
     */
    @SuppressWarnings("unchecked")
    public Class<?> compileWithTraits(final ScriptDefinition definition, final Map<String, Class<?>> parameterTypes,
            final Map<String, Object> constants, Class<?> superType, final String imports) throws ParseException,
            CompilationFailedException {
        String key = this.createContextKey(definition);
        if (this.scriptCache.containsKey(key)) {
            return this.scriptCache.get(key);
        }

        synchronized (CompiledBeanshellExecutor.class) {
            final List<LoadedTrait> allTraits = new ArrayList<LoadedTrait>(definition.traits().size());
            for (ScriptDefinition trait : definition.traits()) {
                allTraits.add(this.compileAsTrait(trait));
            }
            String sourceName = this.getScriptName(definition);
            CompilationSource source =
                    new AbstractCompilationSource(this.generateClassName(definition.name()), parameterTypes, constants,
                            sourceName + ".bsh", superType) {
                        public Reader openReader() {
                            StringBuilder sb = new StringBuilder();
                            final String script = definition.scriptBody();
                            sb.append(script);
                            if (!script.endsWith(";")) {
                                sb.append(";");
                            }
                            sb.append(imports);
                            return new StringReader(sb.toString());
                        }

                        @Override
                        public Map<String, Class<?>> typeAlias() {
                            final Map<String, Class<?>> map = super.typeAlias();
                            CompiledBeanshellExecutor.this.addCommonImports(map);
                            return map;
                        }

                        @Override
                        public List<LoadedTrait> implementedTraits() {
                            return allTraits;
                        }

                    };
            final CompilationResults results = this.compiler.compileOverrideOptions(source, this.getCompilationOptions());
            writeToFile(results, sourceName);
            final Class<? extends CompiledScript> load = (Class<? extends CompiledScript>) getLoader().load(results);
            this.scriptCache.put(key, load);
            return load;
        }
    }

    /**
     * Extracts all the imports from a script string
     * 
     * @param script String
     * @return String
     */
    private String extractImports(String script) {
        StringBuilder sb = new StringBuilder();
        for (String line : script.split("\n")) {
            if (line.trim().startsWith("import ")) {
                sb.append(line);
                sb.append("\n");
            }
        }
        return sb.toString();
    }

    /**
     * Compiles a Script with its Traits
     * 
     * @param ctx the {@link DecomposableScriptExecutionContext}
     * @return {@link Class}
     * @throws Exception e
     */
    public Class<? extends CompiledScript> compileScriptWithTraits(DecomposableScriptExecutionContext<?> ctx) throws Exception {
        final ScriptDefinition initializerScriptDefinition = ctx.getInitializerScriptDefinition();
        Class<?> superType =
                initializerScriptDefinition == null ? Prototype.class : this.compileWithTraits(initializerScriptDefinition,
                        Collections.<String, Class<?>> emptyMap(), Collections.<String, Object> emptyMap(), Prototype.class, "");
        @SuppressWarnings("unchecked")
        Class<? extends CompiledScript> compiledScript =
                (Class<? extends CompiledScript>) this.compileWithTraits(ctx.getScriptDefinition(), ctx.getScriptParameterTypes(), ctx
                        .getParameters().getConstants(), superType, this.extractImports(ctx.getInitScript()));
        return compiledScript;
    }

    /**
     * @param context {@link ScriptExecutionContext}
     * @return {@link CompilationResults}
     * @throws Exception e
     */
    public final CompilationResults doCompileScript(final ScriptExecutionContext<?> context) throws Exception {
        synchronized (CompiledBeanshellExecutor.class) {
            String sourceName = this.getScriptName(context);
            Class<? extends CompiledScript> initializer =
                    this.createInitializer(context.getInitScript(), "bshInitializer" + uuidGenerator++);
            CompilationSource source =
                    new AbstractCompilationSource(this.generateClassName(context), context.getParameters().getParameterDefinitions(),
                            context.getParameters().getConstants(), sourceName + ".bsh", initializer) {
                        public Reader openReader() {
                            StringBuilder sb = new StringBuilder();
                            final String script = context.getScript().trim();
                            sb.append(script);
                            if (!script.endsWith(";")) {
                                sb.append(";");
                            }
                            String[] strings = context.getInitScript().split("\n");
                            for (String string : strings) {
                                if (string.trim().startsWith("import ")) {
                                    sb.append(string);
                                }
                            }
                            return new StringReader(sb.toString());
                        }

                        @Override
                        public Map<String, Class<?>> typeAlias() {
                            final Map<String, Class<?>> map = super.typeAlias();
                            map.putAll(CompiledBeanshellExecutor.this.extractTypeAliases(context.getInitScript()));
                            CompiledBeanshellExecutor.this.addCommonImports(map);
                            return map;
                        }
                    };
            CompilationResults results = this.compiler.compileOverrideOptions(source, this.getCompilationOptions());
            if (DebugRuntimeFactory.ENABLED) {
                LOG.debug("parse time: " + results.getParseTimeMillis());
                LOG.debug("compilation time: " + results.getCompileTimeMillis());
                writeToFile(results, sourceName);
            }
            return results;
        }
    }

    /**
     * @param initScript String
     * @return {@link Map}
     */
    protected Map<? extends String, ? extends Class<?>> extractTypeAliases(String initScript) {
        return new HashMap<String, Class<?>>();
    }

    /**
     * @param context {@link ScriptExecutionContext}
     * @return String
     */
    protected String getScriptName(final ScriptExecutionContext<?> context) {
        final String scriptName = context.getScriptName() == null ? "unnamed_script" : context.getScriptName();
        return scriptName.replaceAll("\\s", "_").replaceAll("[+]", "_");
    }

    /**
     * @param definition the {@link ScriptDefinition}
     * @return String
     */
    protected String getScriptName(ScriptDefinition definition) {
        final String scriptName = definition.name() == null ? "unnamed_script" : definition.name();
        return scriptName.replaceAll("\\s", "_").replaceAll("[+]", "_");
    }

    /**
     * @param context {@link ScriptExecutionContext}
     * @return String
     */
    protected String generateClassName(ScriptExecutionContext<?> context) {
        final String scriptName = context.getScriptName();
        return this.generateClassName(scriptName);
    }

    /**
     * Generates a new unique class name
     * 
     * @param scriptName the script name
     * @return String
     */
    public String generateClassName(final String scriptName) {
        if (scriptName == null) {
            return "generated/compiledBeanshell" + uuidGenerator++;
        }
        final char[] array = scriptName.toCharArray();
        for (int i = 0; i < array.length; i++) {
            if (!Character.isJavaIdentifierPart(array[i])) {
                array[i] = '_';
            }
        }
        return new String(array) + uuidGenerator++;
    }

    /**
     * @return {@link CompilationOptions}
     */
    protected CompilationOptions getCompilationOptions() {
        return new DefaultCompilationOptions() {

            @Override
            public boolean allowUndefinedVariables() {
                return true;
            }

            @Override
            public Set<DebugOptions> debugOptions() {
                if (DebugRuntimeFactory.ENABLED) {
                    EnumSet<DebugOptions> vals = EnumSet.allOf(DebugOptions.class);
                    vals.remove(DebugOptions.VIRTUAL_STACK_FRAMES);
                    return vals;
                }
                EnumSet<DebugOptions> enumSet = EnumSet.noneOf(DebugOptions.class);
                enumSet.add(DebugOptions.LINE_NUMBERS);
                enumSet.add(DebugOptions.LOCAL_VARIABLES);
                return enumSet;
            }
        };
    }

    /**
     * Extrai algumas constantes para serem usadas
     * 
     * @param map {@link Map} de {@link String} para Object
     * @return mapa com as constantes. No momento, apenas enums
     */
    public Map<String, Object> extractConstants(Map<String, Object> map) {
        Map<String, Object> constants = new HashMap<String, Object>();
        for (Entry<String, Object> entry : map.entrySet()) {
            final Object value = entry.getValue();
            if (value instanceof Enum<?>) {
                constants.put(entry.getKey(), value);
            }
        }
        return constants;
    }

    /**
     * {@inheritDoc}
     * 
     * @see jmine.tec.script.WrappingScriptExecutor#getWrappedExecutorType()
     */
    public Class<? extends ScriptExecutor> getWrappedExecutorType() {
        return BeanshellScriptExecutor.class;
    }

    /**
     * Devolve o tipo que declara um enum
     * 
     * @param instance Object
     * @return Class
     */
    public Class<?> getEnumType(Object instance) {
        Class<?> theClass = instance.getClass();
        while (!theClass.isEnum()) {
            theClass = theClass.getSuperclass();
        }
        return theClass;
    }

    /**
     * @return the loader
     */
    public static OpenClassLoader getLoader() {
        return LOADER;
    }

    /**
     * {@inheritDoc}
     * 
     * @see jmine.tec.script.impl.AbstractScriptExecutor#doExecuteScript(jmine.tec.script.ScriptExecutionContext)
     */
    @Override
    protected <R> R doExecuteScript(ScriptExecutionContext<R> context) throws ScriptExecutorException {
        try {
            CompiledScript instance = this.getScript(context);
            instance.executeScript();
        } catch (AbstractException e) {
            throw new BeanshellScriptExecutorException(e.getLocalizedMessageHolder(), e);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BeanshellScriptExecutorException(ERRO_EXECUCAO_SCRIPT.create(e.getMessage(), context.getScriptId()), e);
        }
        return context.getResult();
    }

    /**
     * {@inheritDoc}
     * 
     * @see jmine.tec.script.impl.AbstractScriptExecutor#doGetDependencies(jmine.tec.script.ScriptExecutionContext)
     */
    @Override
    protected <R> R doGetDependencies(ScriptExecutionContext<R> context) throws ScriptExecutorException {
        try {
            CompiledScript instance = this.getScript(context);
            Method method = instance.getClass().getMethod("setDependencies");
            method.invoke(instance);
        } catch (NoSuchMethodException e) {
            // O script não definiu um método para dependencias.
            return null;
        } catch (AbstractException e) {
            throw new BeanshellScriptExecutorException(e.getLocalizedMessageHolder(), e);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BeanshellScriptExecutorException(ERRO_EXECUCAO_SCRIPT.create(e.getMessage(), context.getScriptId()), e);
        }
        return context.getResult();
    }

    /**
     * {@inheritDoc}
     * 
     * @see jmine.tec.script.impl.AbstractScriptExecutor#doGetValidations(jmine.tec.script.ScriptExecutionContext)
     */
    @Override
    protected <R> R doGetValidations(ScriptExecutionContext<R> context) throws ScriptExecutorException {
        try {
            CompiledScript instance = this.getScript(context);
            Method method = instance.getClass().getMethod("setValidations");
            method.invoke(instance);
        } catch (NoSuchMethodException e) {
            // O script não definiu um método para dependencias.
            return null;
        } catch (AbstractException e) {
            throw new BeanshellScriptExecutorException(e.getLocalizedMessageHolder(), e);
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            throw new BeanshellScriptExecutorException(ERRO_EXECUCAO_SCRIPT.create(e.getMessage(), context.getScriptId()), e);
        }
        return context.getResult();
    }

    /**
     * @param map {@link Map}
     */
    private void addCommonImports(final Map<String, Class<?>> map) {
        map.put("Object", Object.class);
        map.put("String", String.class);
        map.put("List", List.class);
        map.put("Collection", Collection.class);
        map.put("Map", Map.class);
        map.put("Iterator", Iterator.class);
        map.put("Iterable", Iterable.class);
        map.put("Set", Set.class);
        map.put("BeanNotFoundException", BeanNotFoundException.class);
        map.put("BigDecimal", BigDecimal.class);
        map.put("Calendar", Calendar.class);
        map.put("Integer", Integer.class);
        map.put("Double", Double.class);
        map.put("Math", Math.class);
        map.put("NumberFormat", NumberFormat.class);
        map.put("DecimalFormat", DecimalFormat.class);
        map.put("SimpleDateFormat", SimpleDateFormat.class);
    }

    /**
     * Verifica se o executor de scripts compilados é capaz de executar o contexto.
     * 
     * @param context context
     * @return boolean
     */
    public boolean canExecute(ScriptExecutionContext<?> context) {
        final String key = this.createContextKey(context);
        if (this.scriptCache.containsKey(key)) {
            return this.scriptCache.get(key) != null;
        }
        try {
            this.compileScript(context);
            return true;
        } catch (Exception e) {
            return false;
        } catch (NoSuchMethodError e) {
            return false;
        }
    }
}
